Skip to content

feat!: streaming SSR and live! + emit! regions - #373

Open
pikaju wants to merge 123 commits into
mainfrom
view-stream
Open

feat!: streaming SSR and live! + emit! regions#373
pikaju wants to merge 123 commits into
mainfrom
view-stream

Conversation

@pikaju

@pikaju pikaju commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR rebuilds the view layer around a lazy View trait and puts streaming on top of it: pages can now send what they have right away and stream the slow parts in when they finish, over the same response and without any client-side fetching.

Views are now lazy

view! no longer renders eagerly into a Result<View>. It evaluates to a plain value implementing the new View trait, and rendering happens later, when the router turns the view into a response. Handlers and components return Result<impl View>:

#[page("/")]
async fn home() -> Result<impl View> {
    Ok(view! { <h1>"Home"</h1> })
}

Follow-on API changes:

  • Layouts receive their inner content as Slot<'_> instead of slot: Result, and interpolate it directly with (slot).
  • Child content is the new Child<'_> type instead of View, usually declared #[default] so the component can be called without children.
  • #[component(boxed)] is gone. Recursive components break the type cycle with .boxed() from the new ViewExt trait, which also provides first() and single() for resolving a view by hand.
  • Route handlers convert through the new AsyncIntoResponse trait (every IntoResponse type implements it for free), so a response can await the view's first content before the status line goes out.

live! and emit!

live! marks a region of the page whose content can still change while the response streams. Its body is ordinary async Rust; emit! renders markup into the region, and every emission replaces the previous one in the browser:

Ok(view! {
    <h1>"Quote of the day"</h1>
    (live! {
        emit! { <p>"Loading..."</p> }?;
        let quote = fetch_quote().await;
        emit! { <blockquote>(quote)</blockquote> }
    })
})

The heading and the loading message reach the browser immediately; once the quote is ready it replaces the loading message in place. The swap needs no client library: the response carries a small script, regions are delimited by marker comments with per-request ids, and each swap arrives as a template the script splices in. emit! evaluates to a Result<EmitToken> and the body returns one, so the type system reminds you that a region must emit at least once and lets the body handle a failed emission (retry, fall back) instead of ending the stream. A live region is a view like any other: components can return one, take one as child content, and several regions on a page stream independently.

suspense and error_boundary

The two most common live-region shapes come prepackaged as built-in components, both thin wrappers over live!/emit!:

suspense(
    fallback: view! { <p>"Loading..."</p> },
    quote()
)

error_boundary(
    fallback: |error| Ok(view! {
        <p>"The stats are unavailable: " (error.to_string())</p>
    }),
    stats()
)

suspense shows the fallback until its child content is ready. error_boundary renders its child and swaps in the fallback view when any part of it fails; returning Err from the fallback rethrows the error up the view tree. This replaces the old pattern of matching on slot: Result in a layout: a layout now wraps its slot in an error_boundary, downcasts the error, and can still set the status code from the fallback view.

Streaming-aware error and redirect handling

Once a response has started streaming, its status line is fixed. An error that escapes after that point ends the stream, and a redirect raised mid-stream is delivered as a client-side navigation (an injected window.location.replace script) instead of a Location header.

@pikaju
pikaju marked this pull request as ready for review September 1, 2026 15:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant